import { SAML, ValidateInResponseTo } from "@node-saml/node-saml"; import { getIDPMetadata, normalizeCertificate, } from "@/lib/saml/idp-metadata"; import { getSPMetadata, } from "@/lib/saml/sp-metadata"; export interface SAMLProfile { nameID?: string; nameIDFormat?: string; attributes?: Record; [key: string]: unknown; } export interface SAMLUser { id: string; email: string; name: string; companyId?: number; techCompanyId?: number; domain?: string; } // SAML 설정 생성 (sync 함수) - 환경변수 기반으로 변경했음 export function createSAMLConfig() { console.log("⚙️ Creating SAML configuration..."); try { const idpMetadata = getIDPMetadata(); const spMetadata = getSPMetadata(); console.log("📋 IdP Metadata loaded:", { entityId: idpMetadata.entityId, ssoUrl: idpMetadata.ssoUrl, organization: idpMetadata.organization, wantAuthnRequestsSigned: idpMetadata.wantAuthnRequestsSigned, }); console.log("📋 SP Metadata loaded:", { entityId: spMetadata.entityId, callbackUrl: spMetadata.callbackUrl, authnRequestsSigned: spMetadata.authnRequestsSigned, }); const config = { callbackUrl: spMetadata.callbackUrl, // IDP 메타데이터 기반 설정 entryPoint: idpMetadata.ssoUrl, // SP Entity ID issuer: spMetadata.entityId, // IDP 인증서 (정규화된 PEM 형식) idpCert: normalizeCertificate(idpMetadata.certificate), privateKey: process.env.SAML_SP_PRIVATE_KEY, // IdP에서 요구하는 설정 identifierFormat: idpMetadata.nameIdFormat, signatureAlgorithm: "sha256" as const, digestAlgorithm: "sha256", // SP 메타데이터 설정 decryptionPvk: process.env.SAML_SP_PRIVATE_KEY, publicCert: process.env.SAML_SP_CERT, // IdP 메타데이터 기반 설정 wantAuthnResponseSigned: idpMetadata.wantAuthnRequestsSigned, wantAssertionsSigned: spMetadata.wantAssertionsSigned, validateInResponseTo: ValidateInResponseTo.never, disableRequestedAuthnContext: true, // HTTP-Redirect 바인딩 설정 authnRequestBinding: undefined, // HTTP-Redirect (GET) 사용 (기본값) skipRequestCompression: false, // Deflate 압축 사용 // 추가 보안 설정 acceptedClockSkewMs: 5000, // 5초 클럭 차이 허용 forceAuthn: false, // IDP Entity ID 설정 idpIssuer: idpMetadata.entityId, }; console.log("✅ SAML Config created:", { callbackUrl: config.callbackUrl, entryPoint: config.entryPoint, issuer: config.issuer, idpIssuer: config.idpIssuer, identifierFormat: config.identifierFormat, hasIdpCert: !!config.idpCert, hasPrivateKey: !!config.privateKey, hasPublicCert: !!config.publicCert, wantAuthnResponseSigned: config.wantAuthnResponseSigned, wantAssertionsSigned: config.wantAssertionsSigned, }); return config; } catch (error) { console.error("💥 Failed to create SAML Config:", error); throw error; } } // SAML AuthnRequest 생성 (서버 액션) export async function createAuthnRequest(): Promise { "use server"; console.log("SSO STEP 2: Create AuthnRequest"); try { const config = createSAMLConfig(); console.log("SAML Config ready for AuthnRequest generation"); const saml = new SAML(config); console.log("SAML instance created, generating authorize URL..."); const startTime = Date.now(); const authorizeUrl = await saml.getAuthorizeUrlAsync( "", // RelayState undefined, // host { additionalParams: {}, // additionalAuthorizeParams: {}, } ); const endTime = Date.now(); // 🔍 SAML AuthnRequest 디코딩 및 분석 try { const urlObj = new URL(authorizeUrl); const samlRequest = urlObj.searchParams.get("SAMLRequest"); if (samlRequest) { console.log("SAML AuthnRequest 분석:"); console.log("1️⃣ 원본 URL:", authorizeUrl); console.log( "2️⃣ URL 디코딩된 SAMLRequest:", decodeURIComponent(samlRequest) ); try { // Base64 디코딩 const base64DecodedBuffer = Buffer.from( decodeURIComponent(samlRequest), "base64" ); const base64DecodedString = base64DecodedBuffer.toString("utf-8"); // XML인지 확인 (XML은 '<'로 시작함) if (base64DecodedString.trim().startsWith("<")) { console.log("Base64 디코딩된 XML (압축 없음):"); console.log("───────────────────────────────────"); console.log(base64DecodedString); console.log("───────────────────────────────────"); // XML 구조 분석 const xmlLines = base64DecodedString .split("\n") .filter((line) => line.trim()); console.log("XML 구조 요약:"); xmlLines.forEach((line, index) => { const trimmed = line.trim(); if ( trimmed.includes(" line.trim()); console.log("XML 구조 요약:"); xmlLines.forEach((line, index) => { const trimmed = line.trim(); if ( trimmed.includes("") || trimmed.includes("AssertionConsumerServiceURL=") ) { console.log(` ${index + 1}: ${trimmed}`); } }); // 중요한 정보 추출 const idMatch = decompressed.match(/ID="([^"]+)"/); const destinationMatch = decompressed.match( /Destination="([^"]+)"/ ); const issuerMatch = decompressed.match( /]*>([^<]+)<\/saml:Issuer>/ ); const acsMatch = decompressed.match( /AssertionConsumerServiceURL="([^"]+)"/ ); console.log("추출된 핵심 정보:"); console.log(` Request ID: ${idMatch ? idMatch[1] : "없음"}`); console.log( ` Destination: ${ destinationMatch ? destinationMatch[1] : "없음" }` ); console.log( ` Issuer: ${issuerMatch ? issuerMatch[1] : "없음"}` ); console.log( ` Callback URL: ${acsMatch ? acsMatch[1] : "없음"}` ); } catch (inflateError) { console.log("❌ Deflate 압축 해제 실패:", inflateError.message); console.log( " 원본 바이너리 데이터 (hex):", base64DecodedBuffer.toString("hex").substring(0, 100) + "..." ); } } } catch (decodeError) { console.log("❌ Base64 디코딩 실패:", decodeError.message); } } } catch (analysisError) { console.log("⚠️ SAML AuthnRequest 분석 중 오류:", analysisError.message); } console.log("✅ SAML AuthnRequest URL generated:", { url: authorizeUrl.substring(0, 100) + "...", fullUrlLength: authorizeUrl.length, processingTime: `${endTime - startTime}ms`, timestamp: new Date().toISOString(), }); return authorizeUrl; } catch (error) { console.error("💥 Failed to create SAML AuthnRequest:", { error: error instanceof Error ? error.message : "Unknown error", stack: error instanceof Error ? error.stack : undefined, timestamp: new Date().toISOString(), }); throw error; } } // SAML Response 검증 및 파싱 (서버 액션) export async function validateSAMLResponse( samlResponse: string ): Promise { "use server"; console.log("🔍 Starting SAML Response validation..."); console.log("📊 SAML Response info:", { responseLength: samlResponse.length, firstChars: samlResponse.substring(0, 50) + "...", isBase64: /^[A-Za-z0-9+/]*={0,2}$/.test(samlResponse), timestamp: new Date().toISOString(), }); // 실제 SAML 검증 수행 (기본값) console.log( "🔐 Using Real SAML validation (SAML_USE_MOCKUP=false or not set)" ); try { console.log("⚙️ Creating SAML instance for validation..."); const saml = new SAML(createSAMLConfig()); console.log("✅ SAML instance created, starting validation..."); const startTime = Date.now(); const result = await saml.validatePostResponseAsync({ SAMLResponse: samlResponse, }); const endTime = Date.now(); // node-saml 라이브러리는 { profile, loggedOut } 형태로 반환 const profile = result.profile; if (!profile) { throw new Error("No profile returned from SAML validation"); } // SAMLProfile 형태로 변환 const samlProfile: SAMLProfile = { nameID: profile.nameID, nameIDFormat: profile.nameIDFormat, attributes: profile.attributes || {}, }; console.log("✅ Real SAML Profile validated successfully:", { nameID: samlProfile.nameID, nameIDFormat: samlProfile.nameIDFormat, attributeCount: Object.keys(samlProfile.attributes || {}).length, attributes: Object.keys(samlProfile.attributes || {}), processingTime: `${endTime - startTime}ms`, timestamp: new Date().toISOString(), }); return samlProfile; } catch (error) { console.error("❌ Real SAML validation error:", { error: error instanceof Error ? error.message : "Unknown error", stack: error instanceof Error ? error.stack : undefined, samlResponseLength: samlResponse.length, timestamp: new Date().toISOString(), }); throw new Error( `SAML validation failed: ${ error instanceof Error ? error.message : "Unknown error" }` ); } } // SAML Profile을 User 객체로 변환 (sync 함수) export function mapSAMLProfileToUser(profile: SAMLProfile): SAMLUser { console.log("🔄 Mapping SAML profile to user:", { nameID: profile.nameID, attributes: profile.attributes, }); // 기본적으로 nameID를 사용하거나 attributes에서 추출 const id = profile.nameID || profile.attributes?.uid?.[0] || profile.attributes?.employeeNumber?.[0] || ""; const email = profile.attributes?.email?.[0] || profile.attributes?.mail?.[0] || profile.nameID || ""; // UTF-8 이름 처리 개선 let name = profile.attributes?.displayName?.[0] || profile.attributes?.cn?.[0] || profile.attributes?.name?.[0] || (profile.attributes?.givenName?.[0] && profile.attributes?.sn?.[0] ? profile.attributes.givenName[0] + " " + profile.attributes.sn[0] : "") || ""; // UTF-8 문자열 정규화 및 검증 if (name && typeof name === "string") { name = name.normalize("NFC").trim(); // 한글이 깨진 경우 감지 및 로그 const hasInvalidChars = /[\uFFFD\x00-\x1F\x7F-\x9F]/.test(name); if (hasInvalidChars) { console.warn("⚠️ Invalid UTF-8 characters detected in name:", { originalName: name, charCodes: [...name].map((c) => c.charCodeAt(0)), hexDump: [...name] .map((c) => "\\x" + c.charCodeAt(0).toString(16).padStart(2, "0")) .join(""), }); } } // 회사 정보는 SSO 로그인 시 없음 const companyId = undefined; const techCompanyId = undefined; const domain = 'evcp'; const user = { id, email, name: name.trim(), companyId, techCompanyId, domain, }; console.log("👤 Mapped user object:", user); return user; } // SAML 로그아웃 URL 생성 (서버 액션) // 로그아웃 지원 안함. 일단 구조만 유사하게 작성해둠. export async function createLogoutRequest(nameID: string): Promise { "use server"; const saml = new SAML(createSAMLConfig()); return await saml.getLogoutUrlAsync( nameID, "", // RelayState { nameIDFormat: "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", } ); }